/*
 * ====================================================================
 * 
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements. See the NOTICE file distributed with this
 * work for additional information regarding copyright ownership. The ASF
 * licenses this file to You under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 * ====================================================================
 * 
 * This software consists of voluntary contributions made by many individuals on
 * behalf of the Apache Software Foundation. For more information on the Apache
 * Software Foundation, please see <http://www.apache.org/>.
 * 
 * [Additional notices, if required by prior licensing conditions]
 * 
 * Alternatively, the contents of this file may be used under the terms of the
 * GNU Lesser General Public License Version 2 or later (the "LGPL"), in which
 * case the provisions of the LGPL are applicable instead of those above. See
 * terms of LGPL at <http://www.gnu.org/copyleft/lesser.txt>. If you wish to
 * allow use of your version of this file only under the terms of the LGPL and
 * not to allow others to use your version of this file under the Apache
 * Software License, indicate your decision by deleting the provisions above and
 * replace them with the notice and other provisions required by the LGPL. If
 * you do not delete the provisions above, a recipient may use your version of
 * this file under either the Apache Software License or the LGPL.
 */

package org.jsslutils.extra.apachehttpclient;

import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketAddress;
import java.net.UnknownHostException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.List;
import java.util.StringTokenizer;

import javax.net.SocketFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.security.auth.x500.X500Principal;

import org.apache.commons.httpclient.ConnectTimeoutException;
import org.apache.commons.httpclient.params.HttpConnectionParams;
import org.apache.commons.httpclient.protocol.SecureProtocolSocketFactory;

/**
 * This is a SecureProtocolSocketFactory for with the SSLContext can be
 * configured. It is based on Sebastian Hauer's StrictSSLProtocolSocketFactory,
 * available in the contribution directory of the Apache HTTP client library
 * 3.1. The main difference is that the SSLContext can be set, which means that
 * the use of client certificates or CRLs may be configured this way. The intent
 * was to use it in conjunction with <a
 * href="http://code.google.com/p/jsslutils/">jSSLutils</a>, but it is not a
 * dependency.
 * 
 * If no SSLContext is set up, the default SSLSocketFactory is used.
 * 
 * @author <a href="mailto:Bruno.Harbulot@manchester.ac.uk">Bruno Harbulot</a>
 * @author <a href="mailto:hauer@psicode.com">Sebastian Hauer</a>
 *         <p>
 *         DISCLAIMER: HttpClient developers DO NOT actively support this
 *         component. The component is provided as a reference material, which
 *         may be inappropriate for use without additional customization.
 *         </p>
 */

public class SslContextedSecureProtocolSocketFactory implements
        SecureProtocolSocketFactory {
    private SSLContext sslContext;

    /** Host name verify flag. */
    private boolean verifyHostname = true;

    /**
     * Constructor for SslContextedSecureProtocolSocketFactory.
     * 
     * @param sslContext
     *            The SSLContext to use for building the SSLSocketFactory. If
     *            this is null, then the default SSLSocketFactory is used.
     * @param verifyHostname
     *            The host name verification flag. If set to <code>true</code>
     *            the SSL sessions server host name will be compared to the host
     *            name returned in the server certificates "Common Name" field
     *            of the "SubjectDN" entry. If these names do not match a
     *            Exception is thrown to indicate this. Enabling host name
     *            verification will help to prevent from man-in-the-middle
     *            attacks. If set to <code>false</code> host name verification
     *            is turned off.
     * 
     *            Code sample:
     * 
     *            <blockquote> Protocol stricthttps = new Protocol( "https", new
     *            SslContextedSecureProtocolSocketFactory(sslContext,true),
     *            443);
     * 
     *            HttpClient client = new HttpClient();
     *            client.getHostConfiguration().setHost("localhost", 443,
     *            stricthttps); </blockquote>
     * 
     */
    public SslContextedSecureProtocolSocketFactory(SSLContext sslContext,
            boolean verifyHostname) {
        this.sslContext = sslContext;
        this.verifyHostname = verifyHostname;
    }

    /**
     * Constructor for SslContextedSecureProtocolSocketFactory. Host name
     * verification will be enabled by default.
     * 
     * @param sslContext
     *            The SSLContext to use for building the SSLSocketFactory. If
     *            this is null, then the default SSLSocketFactory is used.
     */
    public SslContextedSecureProtocolSocketFactory(SSLContext sslContext) {
        this(sslContext, true);
    }

    /**
     * Constructor for SslContextedSecureProtocolSocketFactory. The default
     * SSLSocketFactory will be used by default.
     * 
     * @param verifyHostname
     *            The host name verification flag. If set to <code>true</code>
     *            the SSL sessions server host name will be compared to the host
     *            name returned in the server certificates "Common Name" field
     *            of the "SubjectDN" entry. If these names do not match a
     *            Exception is thrown to indicate this. Enabling host name
     *            verification will help to prevent from man-in-the-middle
     *            attacks. If set to <code>false</code> host name verification
     *            is turned off.
     */
    public SslContextedSecureProtocolSocketFactory(boolean verifyHostname) {
        this(null, verifyHostname);
    }

    /**
     * Constructor for SslContextedSecureProtocolSocketFactory. By default, the
     * default SSLSocketFactory will be used and host name verification will be
     * enabled.
     */
    public SslContextedSecureProtocolSocketFactory() {
        this(null, true);
    }

    /**
     * Set the host name verification flag.
     * 
     * @param verifyHostname
     *            The host name verification flag. If set to <code>true</code>
     *            the SSL sessions server host name will be compared to the host
     *            name returned in the server certificates "Common Name" field
     *            of the "SubjectDN" entry. If these names do not match a
     *            Exception is thrown to indicate this. Enabling host name
     *            verification will help to prevent from man-in-the-middle
     *            attacks. If set to <code>false</code> host name verification
     *            is turned off.
     */
    public synchronized void setHostnameVerification(boolean verifyHostname) {
        this.verifyHostname = verifyHostname;
    }

    /**
     * Gets the status of the host name verification flag.
     * 
     * @return Host name verification flag. Either <code>true</code> if host
     *         name verification is turned on, or <code>false</code> if host
     *         name verification is turned off.
     */
    public synchronized boolean getHostnameVerification() {
        return verifyHostname;
    }

    /**
     * @see SecureProtocolSocketFactory#createSocket(java.lang.String,int,java.net.InetAddress,int)
     */
    public Socket createSocket(String host, int port, InetAddress clientHost,
            int clientPort) throws IOException, UnknownHostException {
        SSLSocketFactory sf = (SSLSocketFactory) getSslSocketFactory();
        SSLSocket sslSocket = (SSLSocket) sf.createSocket(host, port,
                clientHost, clientPort);
        verifyHostname(sslSocket);

        return sslSocket;
    }

    /**
     * Attempts to get a new socket connection to the given host within the
     * given time limit.
     * <p>
     * This method employs several techniques to circumvent the limitations of
     * older JREs that do not support connect timeout. When running in JRE 1.4
     * or above reflection is used to call Socket#connect(SocketAddress
     * endpoint, int timeout) method. When executing in older JREs a controller
     * thread is executed. The controller thread attempts to create a new socket
     * within the given limit of time. If socket constructor does not return
     * until the timeout expires, the controller terminates and throws an
     * {@link ConnectTimeoutException}
     * </p>
     * 
     * @param host
     *            the host name/IP
     * @param port
     *            the port on the host
     * @param clientHost
     *            the local host name/IP to bind the socket to
     * @param clientPort
     *            the port on the local machine
     * @param params
     *            {@link HttpConnectionParams Http connection parameters}
     * 
     * @return Socket a new socket
     * 
     * @throws IOException
     *             if an I/O error occurs while creating the socket
     * @throws UnknownHostException
     *             if the IP address of the host cannot be determined
     */
    public Socket createSocket(final String host, final int port,
            final InetAddress localAddress, final int localPort,
            final HttpConnectionParams params) throws IOException,
            UnknownHostException, ConnectTimeoutException {
        if (params == null) {
            throw new IllegalArgumentException("Parameters may not be null");
        }
        int timeout = params.getConnectionTimeout();
        Socket socket = null;

        SocketFactory socketfactory = getSslSocketFactory();
        if (timeout == 0) {
            socket = socketfactory.createSocket(host, port, localAddress,
                    localPort);
        } else {
            socket = socketfactory.createSocket();
            SocketAddress localaddr = new InetSocketAddress(localAddress,
                    localPort);
            SocketAddress remoteaddr = new InetSocketAddress(host, port);
            socket.bind(localaddr);
            socket.connect(remoteaddr, timeout);
        }
        verifyHostname((SSLSocket) socket);
        return socket;
    }

    /**
     * @see SecureProtocolSocketFactory#createSocket(java.lang.String,int)
     */
    public Socket createSocket(String host, int port) throws IOException,
            UnknownHostException {
        SSLSocketFactory sf = (SSLSocketFactory) getSslSocketFactory();
        SSLSocket sslSocket = (SSLSocket) sf.createSocket(host, port);
        verifyHostname(sslSocket);

        return sslSocket;
    }

    /**
     * @see SecureProtocolSocketFactory#createSocket(java.net.Socket,java.lang.String,int,boolean)
     */
    public Socket createSocket(Socket socket, String host, int port,
            boolean autoClose) throws IOException, UnknownHostException {
        SSLSocketFactory sf = (SSLSocketFactory) getSslSocketFactory();
        SSLSocket sslSocket = (SSLSocket) sf.createSocket(socket, host, port,
                autoClose);
        verifyHostname(sslSocket);

        return sslSocket;
    }

    /**
     * Describe <code>verifyHostname</code> method here.
     * 
     * @param socket
     *            a <code>SSLSocket</code> value
     * @exception SSLPeerUnverifiedException
     *                If there are problems obtaining the server certificates
     *                from the SSL session, or the server host name does not
     *                match with the "Common Name" in the server certificates
     *                SubjectDN.
     * @exception UnknownHostException
     *                If we are not able to resolve the SSL sessions returned
     *                server host name.
     */
    private void verifyHostname(SSLSocket socket)
            throws SSLPeerUnverifiedException, UnknownHostException {
        synchronized (this) {
            if (!verifyHostname)
                return;
        }

        SSLSession session = socket.getSession();
        String hostname = session.getPeerHost();
        try {
            InetAddress.getByName(hostname);
        } catch (UnknownHostException uhe) {
            throw new UnknownHostException("Could not resolve SSL sessions "
                    + "server hostname: " + hostname);
        }

        X509Certificate[] certs = (X509Certificate[]) session
                .getPeerCertificates();
        if (certs == null || certs.length == 0)
            throw new SSLPeerUnverifiedException(
                    "No server certificates found!");

        X500Principal subjectDN = certs[0].getSubjectX500Principal();

        // get the common names from the first cert
        List<String> cns = getCNs(subjectDN);
        boolean foundHostName = false;
        for (String cn : cns) {
            if (hostname.equalsIgnoreCase(cn)) {
                foundHostName = true;
                break;
            }
        }
        if (!foundHostName) {
            throw new SSLPeerUnverifiedException(
                    "HTTPS hostname invalid: expected '" + hostname
                            + "', received '" + cns + "'");
        }
    }

    /**
     * Parses a X.500 distinguished name for the values of the "Common Name"
     * fields. This is done a bit sloppy right now and should probably be done a
     * bit more according to <code>RFC 2253</code>.
     * 
     * @param subjectDN
     *            an X.500 Principal from an X.509 certificate.
     * @return the values of the "Common Name" fields.
     */
    private List<String> getCNs(X500Principal subjectDN) {
        List<String> cns = new ArrayList<String>();

        StringTokenizer st = new StringTokenizer(subjectDN.getName(), ",");
        while (st.hasMoreTokens()) {
            String cnField = st.nextToken();
            if (cnField.startsWith("CN=")) {
                cns.add(cnField.substring(3));
            }
        }
        return cns;
    }

    /**
     * Returns the SSLSocketFactory to use to create the sockets. If the
     * sslContext is non-null, this is built from the sslContext; otherwise,
     * this is the default SSLSocketFactory.
     * 
     * @return the SSLSocketFactory to use to create the sockets.
     */
    protected SSLSocketFactory getSslSocketFactory() {
        SSLSocketFactory sslSocketFactory = null;
        synchronized (this) {
            if (this.sslContext != null) {
                sslSocketFactory = this.sslContext.getSocketFactory();
            }
        }
        if (sslSocketFactory == null) {
            sslSocketFactory = (SSLSocketFactory) SSLSocketFactory.getDefault();
        }
        return sslSocketFactory;
    }

    /**
     * Sets the SSLContext to use.
     * 
     * @param sslContext
     *            SSLContext to use.
     */
    public synchronized void setSSLContext(SSLContext sslContext) {
        this.sslContext = sslContext;
    }
}
